The Battle of Hotels in the Neighborhoods

Juan Miguel Bejarano

13/03/2021

Introduction: Business Problem

In this project we will try to find an optimal location for a hotel. Specifically, this report will be targeted to stakeholders interested in opening a new Hotel in Seville, Spain.

Since there are lots of them in Seville we will try to detect locations that are not already crowded with hotels. We are also particularly interested in areas with no hotels in vicinity. We would also prefer locations as close to city center as possible, assuming that first two conditions are met.

We will use our data science powers to generate a few most promissing neighborhoods based on this criteria. Advantages of each area will then be clearly expressed so that best possible final location can be chosen by stakeholders.

Data

Based on definition of our problem, factors that will influence our decission are:

  • number of existing hotels in the neighborhood (any type of hotel)
  • number of near hotels and distance in the neighborhood, if any
  • distance of neighborhood from city center

We decided to use regularly spaced grid of locations, centered around city center, to define our neighborhoods.

Following data sources will be needed to extract/generate the required information:

  • centers of candidate areas will be generated algorithmically and approximate addresses of centers of those areas will be obtained using Geopy reverse geocoding
  • number of hotels and their type and location in every neighborhood will be obtained using Foursquare API
  • coordinate of Seville center will be obtained using Geopy geocoding of well known Seville location (Seville Center)

Neighborhood Candidates

Let's create latitude & longitude coordinates for centroids of our candidate neighborhoods. We will create a grid of cells covering our area of interest which is aprox. 12x12 killometers centered around Seville city center.

Let's first define the latitude & longitude of Seville city center

In [1]:
seville_center=[37.38905561866877,-5.994854457572518]
print('Coordinate of Seville City Center: {}'.format(seville_center))
Coordinate of Seville City Center: [37.38905561866877, -5.994854457572518]

Now let's create a grid of area candidates, equaly spaced, centered around city center and within ~6km from Seville Center. Our neighborhoods will be defined as circular areas with a radius of 300 meters, so our neighborhood centers will be 600 meters apart.

To accurately calculate distances we need to create our grid of locations in Cartesian 2D coordinate system which allows us to calculate distances in meters (not in latitude/longitude degrees). Then we'll project those coordinates back to latitude/longitude degrees to be shown on Folium map. So let's create functions to convert between WGS84 spherical coordinate system (latitude/longitude degrees) and UTM Cartesian coordinate system (X/Y coordinates in meters).

In [7]:
#!pip3 install pyproj
#!pip3 install shapely

import requests
import pyproj
import math
import warnings
warnings.filterwarnings('ignore')

def lonlat_to_xy(lon, lat):
    proj_latlon = pyproj.Proj(proj='latlong',datum='WGS84')
    proj_xy = pyproj.Proj(proj="utm", zone=33, datum='WGS84')
    xy = pyproj.transform(proj_latlon, proj_xy, lon, lat)
    return xy[0], xy[1]

def xy_to_lonlat(x, y):
    proj_latlon = pyproj.Proj(proj='latlong',datum='WGS84')
    proj_xy = pyproj.Proj(proj="utm", zone=33, datum='WGS84')
    lonlat = pyproj.transform(proj_xy, proj_latlon, x, y)
    return lonlat[0], lonlat[1]

def calc_xy_distance(x1, y1, x2, y2):
    dx = x2 - x1
    dy = y2 - y1
    return math.sqrt(dx*dx + dy*dy)

print('Coordinate transformation check')
print('-------------------------------')
print('Seville center \tlongitude={}, \t latitude={}'.format(seville_center[1], seville_center[0]))
x, y = lonlat_to_xy(seville_center[1], seville_center[0])
print('Seville center \tUTM X={}, \t UTM Y={}'.format(x, y))
lo, la = xy_to_lonlat(x, y)
print('Seville center \tlongitude={}, \t latitude={}'.format(lo, la))
Coordinate transformation check
-------------------------------
Seville center 	longitude=-5.994854457572518, 	 latitude=37.38905561866877
Seville center 	UTM X=-1368887.853868162, 	 UTM Y=4351411.180551322
Seville center 	longitude=-5.994854457572517, 	 latitude=37.38905561866877

Let's create a hexagonal grid of cells: we offset every other row, and adjust vertical row spacing so that every cell center is equally distant from all it's neighbors.

In [8]:
seville_center_x, seville_center_y = lonlat_to_xy(seville_center[1], seville_center[0]) # City center in Cartesian coordinates

k = math.sqrt(3) / 2 # Vertical offset for hexagonal grid cells
x_min = seville_center_x - 6000
x_step = 600
y_min = seville_center_y - 6000 - (int(21/k)*k*600 - 12000)/2
y_step = 600 * k 

latitudes = []
longitudes = []
distances_from_center = []
xs = []
ys = []
for i in range(0, int(21/k)):
    y = y_min + i * y_step
    x_offset = 300 if i%2==0 else 0
    for j in range(0, 21):
        x = x_min + j * x_step + x_offset
        distance_from_center = calc_xy_distance(seville_center_x, seville_center_y, x, y)
        if (distance_from_center <= 6001):
            lon, lat = xy_to_lonlat(x, y)
            latitudes.append(lat)
            longitudes.append(lon)
            distances_from_center.append(distance_from_center)
            xs.append(x)
            ys.append(y)

print(len(latitudes), 'candidate neighborhood centers generated.')
364 candidate neighborhood centers generated.

Let's visualize the data we have so far: city center location and candidate neighborhood centers:

In [14]:
#!pip3 install folium

import folium

map_seville = folium.Map(location=seville_center, zoom_start=13)
folium.Marker(seville_center, popup='Seville').add_to(map_seville)
for lat, lon in zip(latitudes, longitudes):
    folium.Circle([lat, lon], radius=300, color='gray', fill=False).add_to(map_seville)
map_seville
Out[14]:
Make this Notebook Trusted to load map: File -> Trust Notebook

OK, we now have the coordinates of centers of neighborhoods/areas to be evaluated, equally spaced (distance from every point to it's neighbors is exactly the same) and within ~6km from Seville (Center).

Let's now use Geopy to get approximate addresses of those locations.

In [15]:
from geopy.geocoders import Nominatim
geolocator = Nominatim(user_agent="Best Place, Best Hotel App")
addresses=[]
for lat, lon in zip(latitudes, longitudes):
    geolocator = Nominatim(user_agent="APP")
    address=geolocator.reverse(str(lat)+ ',' + str(lon))
    if address is None:
        address = 'NO ADDRESS'
    addresses.append(address)

print('Reversing Geolocation Done!')
Reversing Geolocation Done!
In [18]:
#Print some addesses
addresses[15:20]
Out[18]:
[Location(SE-30; A-4, Los Bermejales, Elcano-Los Bermejales, Distrito Bellavista-La Palmera, Sevilla, Andalucía, 41013, España, (37.35025067825385, -5.9613895135949955, 0.0)),
 Location(Bellavista, Distrito Bellavista-La Palmera, Sevilla, Andalucía, 41089, España, (37.3489506, -5.9526032, 0.0)),
 Location(Puerto Gelves, Calle Teniente Alcalde Rafael Zamora, Gelves, Sevilla, Andalucía, 41920, España, (37.3411258, -6.0235248, 0.0)),
 Location(Torrecuéllar, Los Remedios, Sevilla, Andalucía, 41920, España, (37.3386333, -6.0147175, 0.0)),
 Location(Estación depuradora de aguas residuales Oeste - Tablada, Carretera de la Esclusa, Los Remedios, Sevilla, Andalucía, España, (37.3452446, -6.008429715072667, 0.0))]

Looking good. Let's now place all this into a Pandas dataframe.

In [19]:
import pandas as pd

df_locations = pd.DataFrame({'Address': [x[0] for x in addresses], #Comprehension list to filter Address
                             'Latitude': latitudes,
                             'Longitude': longitudes,
                             'UTM X': x,
                             'UTM Y': y,
                             'Distance from center': distances_from_center})

df_locations.head(50)
Out[19]:
Address Latitude Longitude UTM X UTM Y Distance from center
0 Zona Franca, Los Remedios, Sevilla, Andalucía,... 37.337436 -5.999789 -1.362888e+06 4.357127e+06 5992.495307
1 Tecade Yard, Calle Nao Concepción, Polígono de... 37.338611 -5.993467 -1.362888e+06 4.357127e+06 5840.376700
2 Avenida de Palmas Altas, Bellavista, Distrito ... 37.339787 -5.987144 -1.362888e+06 4.357127e+06 5747.173218
3 SE-30; A-66, Bellavista, Distrito Bellavista-L... 37.340962 -5.980821 -1.362888e+06 4.357127e+06 5715.767665
4 Clínica FREMAP, Avenida de Jerez, Los Bermejal... 37.342136 -5.974497 -1.362888e+06 4.357127e+06 5747.173218
5 SE-30; A-4, Los Bermejales, Elcano-Los Bermeja... 37.343311 -5.968173 -1.362888e+06 4.357127e+06 5840.376700
6 Camino de Villanueva del Pítamo, Bellavista, D... 37.344485 -5.961849 -1.362888e+06 4.357127e+06 5992.495307
7 Estación depuradora de aguas residuales Oeste ... 37.340043 -6.010548 -1.362888e+06 4.357127e+06 5855.766389
8 Zona Franca, Los Remedios, Sevilla, Andalucía,... 37.341220 -6.004226 -1.362888e+06 4.357127e+06 5604.462508
9 Calle Nao Victoria, Polígono de Astilleros, Lo... 37.342395 -5.997903 -1.362888e+06 4.357127e+06 5408.326913
10 Carretera del Copero, Bellavista, Distrito Bel... 37.343571 -5.991580 -1.362888e+06 4.357127e+06 5273.518749
11 Avenida de Las Razas, Bellavista, Distrito Bel... 37.344746 -5.985257 -1.362888e+06 4.357127e+06 5204.805472
12 Laboratorios Orma, Avenida de Alemania, Los Be... 37.345921 -5.978933 -1.362888e+06 4.357127e+06 5204.805472
13 Calle Santa María de la Cabeza, Bellavista, Di... 37.347096 -5.972609 -1.362888e+06 4.357127e+06 5273.518749
14 Real Club Pineda de Sevilla, Camino de Villanu... 37.348270 -5.966285 -1.362888e+06 4.357127e+06 5408.326913
15 SE-30; A-4, Los Bermejales, Elcano-Los Bermeja... 37.349445 -5.959960 -1.362888e+06 4.357127e+06 5604.462508
16 Bellavista, Distrito Bellavista-La Palmera, Se... 37.350618 -5.953634 -1.362888e+06 4.357127e+06 5855.766389
17 Puerto Gelves, Calle Teniente Alcalde Rafael Z... 37.342650 -6.021308 -1.362888e+06 4.357127e+06 5901.694672
18 Torrecuéllar, Los Remedios, Sevilla, Andalucía... 37.343827 -6.014986 -1.362888e+06 4.357127e+06 5556.077753
19 Estación depuradora de aguas residuales Oeste ... 37.345003 -6.008663 -1.362888e+06 4.357127e+06 5256.424640
20 Zona de actividades logísticas Batán II, Los R... 37.346179 -6.002340 -1.362888e+06 4.357127e+06 5010.987927
21 XPO Logistics, Puente del Centenario, Bellavis... 37.347355 -5.996017 -1.362888e+06 4.357127e+06 4828.043082
22 Avenida de Las Razas, Sector Sur-La Palmera-Re... 37.348531 -5.989694 -1.362888e+06 4.357127e+06 4714.870094
23 Edificio Administrativo Los Bermejales Junta d... 37.349706 -5.983370 -1.362888e+06 4.357127e+06 4676.537180
24 Avenida de Jerez, Los Bermejales, Elcano-Los B... 37.350881 -5.977046 -1.362888e+06 4.357127e+06 4714.870094
25 Real Club Pineda de Sevilla, Camino de Villanu... 37.352056 -5.970721 -1.362888e+06 4.357127e+06 4828.043082
26 Real Club Pineda de Sevilla, Camino de Villanu... 37.353230 -5.964396 -1.362888e+06 4.357127e+06 5010.987927
27 SE-30; A-4, Los Bermejales, Elcano-Los Bermeja... 37.354405 -5.958071 -1.362888e+06 4.357127e+06 5256.424640
28 Bellavista, Distrito Bellavista-La Palmera, Se... 37.355578 -5.951745 -1.362888e+06 4.357127e+06 5556.077753
29 Edificio mixto interdepartamental, 45, Calle M... 37.356752 -5.945419 -1.362888e+06 4.357127e+06 5901.694672
30 De Sevilla a La Puebla del Río, Gelves, Sevill... 37.346432 -6.025746 -1.362888e+06 4.357127e+06 5700.000000
31 Gelves, Sevilla, Andalucía, España 37.347609 -6.019423 -1.362888e+06 4.357127e+06 5307.541804
32 Estación depuradora de aguas residuales Oeste ... 37.348786 -6.013101 -1.362888e+06 4.357127e+06 4956.813493
33 Estación depuradora de aguas residuales Oeste ... 37.349963 -6.006778 -1.362888e+06 4.357127e+06 4657.252409
34 Zona de actividades logísticas Batán, Los Reme... 37.351139 -6.000455 -1.362888e+06 4.357127e+06 4419.275959
35 Terminal de contenedores, Los Remedios, Sevill... 37.352315 -5.994131 -1.362888e+06 4.357127e+06 4253.234064
36 AAVV Los Andes, Calle Ebro, Heliópolis, Distri... 37.353491 -5.987807 -1.362888e+06 4.357127e+06 4167.733197
37 Calle Cauca, Heliópolis, Distrito Bellavista-L... 37.354666 -5.981483 -1.362888e+06 4.357127e+06 4167.733197
38 Calle Jandula, Pedro Salvador - Las Palmeritas... 37.355841 -5.975158 -1.362888e+06 4.357127e+06 4253.234064
39 Barriada Murillo, Polígono Sur, Distrito Sur, ... 37.357016 -5.968833 -1.362888e+06 4.357127e+06 4419.275959
40 Calle Edipo Rey, Martínez Montañés, Polígono S... 37.358190 -5.962507 -1.362888e+06 4.357127e+06 4657.252409
41 A-376, Martínez Montañés, Polígono Sur, Distri... 37.359365 -5.956181 -1.362888e+06 4.357127e+06 4956.813493
42 Molino de San Juan de Teatinos, Carretera de S... 37.360538 -5.949855 -1.362888e+06 4.357127e+06 5307.541804
43 Polígono Sur, Distrito Sur, Sevilla, Andalucía... 37.361712 -5.943528 -1.362888e+06 4.357127e+06 5700.000000
44 Calle París, San Juan de Aznalfarache, Sevilla... 37.350214 -6.030184 -1.362888e+06 4.357127e+06 5556.077753
45 Mercadona, Calle Nao Victoria, Gelves, Sevilla... 37.351392 -6.023862 -1.362888e+06 4.357127e+06 5117.616633
46 Dehesa de Tablada, Los Remedios, Sevilla, Anda... 37.352569 -6.017539 -1.362888e+06 4.357127e+06 4714.870094
47 Dehesa de Tablada, Los Remedios, Sevilla, Anda... 37.353746 -6.011216 -1.362888e+06 4.357127e+06 4357.751714
48 Los Remedios, Sevilla, Andalucía, 41011, España 37.354923 -6.004893 -1.362888e+06 4.357127e+06 4058.324778
49 Terminal de contenedores, Los Remedios, Sevill... 37.356099 -5.998569 -1.362888e+06 4.357127e+06 3830.143600

...and let's now save/persist this data into local file.

In [34]:
import pickle

df_locations.to_pickle('./locations.pkl')   

Foursquare Information Gathering

Now that we have our location candidates, let's use Foursquare API to get info on hotels in each neighborhood.

We're interested in venues in 'food' category, but only those that are proper hotels - coffe shops, pizza places, bakeries etc. are not direct competitors so we don't care about those. So we will include in out list only venues that have 'hotel' in category name, and we'll make sure to detect and include all the subcategories of specific 'hotel' category, as we need info on hotels in the neighborhood.

Foursquare credentials are defined in hidden cell bellow.

In [21]:
#Credentials:
client_id='***'
client_secret='***'
In [22]:
# Category IDs corresponding to Hotels were taken from Foursquare web site (https://developer.foursquare.com/docs/resources/categories):

hotel_category = '4bf58dd8d48988d1fa931735' # 'Root' category for all hotel-related venues

hotel_categories = ['4bf58dd8d48988d1f8931735','4f4530a74b9074f6e4fb0100','4bf58dd8d48988d1ee931735',
                    '4bf58dd8d48988d132951735','5bae9231bedf3950379f89cb','4bf58dd8d48988d1fb931735',
                    '4bf58dd8d48988d12f951735','56aa371be4b08b9a8d5734e1']

#Defining what is hotel
def is_hotel(categories, specific_filter=None):
    hotel_words = ['hotel','resort']
    hotel = False
    specific = False
    for c in categories:
        category_name = c[0].lower()
        category_id = c[1]
        for r in hotel_words:
            if r in category_name:
                hotel = True
        if 'inn' in category_name:
            hotel = False
        if 'hostal' in category_name:
            hotel = False
        if not(specific_filter is None) and (category_id in specific_filter):
            specific = True
            hotel = True
    return hotel, specific

def get_categories(categories):
    return [(cat['name'], cat['id']) for cat in categories]

def format_address(location):
    address = ', '.join(location['formattedAddress'])
    address = address.replace(', España', '')
    address = address.replace(', Spain', '')
    return address

def get_venues_near_location(lat, lon, category, client_id, client_secret, radius=1000, limit=100):
    version = '20180605'
    url = 'https://api.foursquare.com/v2/venues/explore?client_id={}&client_secret={}&v={}&ll={},{}&categoryId={}&radius={}&limit={}'.format(
        client_id, client_secret, version, lat, lon, category, radius, limit)
    try:
        results = requests.get(url).json()['response']['groups'][0]['items']
        venues = [(item['venue']['id'],
                   item['venue']['name'],
                   get_categories(item['venue']['categories']),
                   (item['venue']['location']['lat'], item['venue']['location']['lng']),
                   format_address(item['venue']['location']),
                   item['venue']['location']['distance']) for item in results]
        print(venues)
    except Exception as e:
        venues = []
        print(e)
    return venues
In [25]:
# Let's now go over our neighborhood locations and get nearby hotels; we'll also maintain a dictionary of all found hotels.

import pickle
import time
import requests

def get_hotels(lats, lons):
    hotels = {}
    location_hotels = []

    print('Obtaining venues around candidate locations:', end='')
    for lat, lon in zip(lats, lons):
        # Using radius=350 to meke sure we have overlaps/full coverage so we don't miss any hotel (we're using dictionaries to remove any duplicates resulting from area overlaps)
        print(lat,lon,hotel_category,client_id,client_secret)
        venues = get_venues_near_location(lat, lon, hotel_category, client_id, client_secret, radius=350, limit=100)
        #time.sleep(1000)
        area_hotels = []
        for venue in venues:
            venue_id = venue[0]
            venue_name = venue[1]
            venue_categories = venue[2]
            venue_latlon = venue[3]
            venue_address = venue[4]
            venue_distance = venue[5]
            is_hot=is_hotel(venue_categories)
            if is_hot:
                x, y = lonlat_to_xy(venue_latlon[1], venue_latlon[0])
                hotel = (venue_id, venue_name, venue_latlon[0], venue_latlon[1], venue_address, venue_distance, x, y)
                if venue_distance<=300:
                    area_hotels.append(hotel)
                hotels[venue_id] = hotel
               
        location_hotels.append(area_hotels)
        print(' .', end='')
    print(' done.')
    return hotels, location_hotels

# Try to load from local file system in case we did this before
hotels = {}

location_hotels = []
loaded = False
try:
    with open('hotels_350.pkl', 'rb') as f:
        hotels = pickle.load(f)
    with open('location_hotels_350.pkl', 'rb') as f:
        location_hotels = pickle.load(f)
    print('Hotel data loaded.')
    loaded = True
except:
    pass

# If load failed use the Foursquare API to get the data
if not loaded:
    hotels, location_hotels = get_hotels(latitudes, longitudes)
    
    # Let's persists this in local file system
    with open('hotels_350.pkl', 'wb') as f:
        pickle.dump(hotels, f)
    with open('location_hotels_350.pkl', 'wb') as f:
        pickle.dump(location_hotels, f)
Hotel data loaded.
In [26]:
import numpy as np

print('Total number of hotels:', len(hotels))
print('Average number of hotels in neighborhood:', np.array([len(r) for r in location_hotels]).mean())
Total number of hotels: 246
Average number of hotels in neighborhood: 0.6483516483516484
In [27]:
print('List of all hotels')
print('-----------------------')
for r in list(hotels.values())[:10]:
    print(r)
print('...')
print('Total:', len(hotels))
List of all hotels
-----------------------
('4cf296e194feb1f7221d1eba', 'Hotel Holos', 37.35514653199326, -5.984783566581983, 'C. Uruguay, 8, 41012 Sevilla AL', 296, -1368872.3117560293, 4347377.557459985)
('4bc8f4b7762beee176bd3d38', 'Hotel Silken Al-Ándalus Palace', 37.353819584938826, -5.980041909235123, 'Paseo de la Palmera (C. Paraná), 41012 Sevilla Andalucía', 158, -1368480.4212380208, 4347128.63690257)
('531066fb498ea4ff13985941', 'recepcion hotel Silken Al Andaluz Palace', 37.35383224487305, -5.979882717132568, 'España', 169, -1368465.7629139465, 4347126.729700698)
('4e2c99cfb61cd0108381dc91', 'Al Andalus Hotel Swimming Pool', 37.353668141628695, -5.979149070050811, 'Avenida de la Palmera (Calle Paraná), 41012 Sevilla Andalucía', 234, -1368404.045550194, 4347092.863646933)
('4e2c9edf62e144b5d3b93942', 'Al Andalus Hotel Resting Quarters', 37.353800530357475, -5.980075306590861, 'Avenida de la Palmera (Calle Paraná), 41012 Sevilla Andalucía', 157, -1368483.926722452, 4347127.188371099)
('4e2c9d126284b96d7b8c710e', 'Al Andalus Hotel Poolside Cocktail Bar', 37.35385202526245, -5.97992968213557, 'Avenida de la Palmera (Calle Paraná), 41012 Sevilla Andalucía', 164, -1368469.4704264842, 4347129.943296863)
('4bba34041261d13aa65bea98', 'AC Hotel Ciudad de Sevilla', 37.364188370600864, -5.982959568500519, 'Ave Manuel Siurot 25, 41013 Sevilla Andalucía', 128, -1368470.8592502826, 4348358.641406733)
('4da040e66fd2548190275a9b', 'Albergue Inturjoven', 37.36457005940174, -5.986497477825297, 'Isaac Peral, Sevilla Andalucía', 340, -1368779.1757421158, 4348475.797712583)
('4c9f6111d3c2b60c06d2d3bc', 'Hotel Sevilla Palmera', 37.365119378200184, -5.978365207195594, 'Cardenal Ilundain 28, 41013 Sevilla Andalucía', 83, -1368033.0348303523, 4348367.35046529)
('4b98c44ff964a520c74e35e3', 'Hotel NH Collection Sevilla', 37.375111826244144, -5.976248515523416, 'Avda Diego Martinez Barrio, 8, 41013 Sevilla Andalucía', 215, -1367580.362201829, 4349449.442255759)
...
Total: 246
In [28]:
print('Hotels around location')
print('---------------------------')
for i in range(100, 110):
    rs = location_hotels[i][:8]
    names = ', '.join([r[1] for r in rs])
    print('Hotels around location {}: {}'.format(i+1, names))
Hotels around location
---------------------------
Hotels around location 101: 
Hotels around location 102: 
Hotels around location 103: 
Hotels around location 104: 
Hotels around location 105: 
Hotels around location 106: 
Hotels around location 107: 
Hotels around location 108: Hotel NH Collection Sevilla
Hotels around location 109: 
Hotels around location 110: 

Let's now see all the collected hotels in our area of interest on map.

In [29]:
map_seville = folium.Map(location=seville_center, zoom_start=13)
folium.Marker(seville_center, popup='Seville').add_to(map_seville)
for hot in hotels.values():
    lat = hot[2]; lon = hot[3]
    is_hot = hot[6]
    color = 'purple'
    folium.CircleMarker([lat, lon], radius=3, color=color, fill=True, fill_color=color, fill_opacity=1).add_to(map_seville)
map_seville
Out[29]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Looking good. So now we have all the hotels in area within few kilometers from Seville Center. We also know which hotels exactly are in vicinity of every neighborhood candidate center.

This concludes the data gathering phase - we're now ready to use this data for analysis to produce the report on optimal locations for a new hotel!

Methodology

In this project we will direct our efforts on detecting areas of Seville that have low hotel density. We will limit our analysis to area ~6km around city center.

In first step we have collected the required data: location and type (category) of every hotel within 6km from Seville center (Seville Center). We have also identified hotels (according to Foursquare categorization).

Second step in our analysis will be calculation and exploration of 'hotel density' across different areas of Seville - we will use heatmaps to identify a few promising areas close to center with low number of hotels in general focusing our attention on those areas.

In third and final step we will focus on most promising areas and within those create clusters of locations that meet some basic requirements established in discussion with stakeholders: we will take into consideration locations with no more than two hotels in radius of 250 meters, and we want locations without hotels in radius of 400 meters. We will present map of all such locations but also create clusters (using k-means clustering) of those locations to identify general zones / neighborhoods / addresses which should be a starting point for final 'street level' exploration and search for optimal venue location by stakeholders.

Analysis

Let's perform some basic explanatory data analysis and derive some additional info from our raw data. First let's count the number of hotels in every area candidate:

In [35]:
location_hotels_count = [len(hot) for hot in location_hotels]

df_locations['Hotels in area'] = location_hotels_count

print('Average number of hotels in every area with radius=300m:', np.array(location_hotels_count).mean())

df_locations.head(10)
Average number of hotels in every area with radius=300m: 0.6483516483516484
Out[35]:
Address Latitude Longitude UTM X UTM Y Distance from center Hotels in area Distance to near Hotel
0 Zona Franca, Los Remedios, Sevilla, Andalucía,... 37.337436 -5.999789 -1.362888e+06 4.357127e+06 5992.495307 0 2475.036066
1 Tecade Yard, Calle Nao Concepción, Polígono de... 37.338611 -5.993467 -1.362888e+06 4.357127e+06 5840.376700 0 2075.368158
2 Avenida de Palmas Altas, Bellavista, Distrito ... 37.339787 -5.987144 -1.362888e+06 4.357127e+06 5747.173218 0 1748.671139
3 SE-30; A-66, Bellavista, Distrito Bellavista-L... 37.340962 -5.980821 -1.362888e+06 4.357127e+06 5715.767665 0 1478.830320
4 Clínica FREMAP, Avenida de Jerez, Los Bermejal... 37.342136 -5.974497 -1.362888e+06 4.357127e+06 5747.173218 0 1402.272846
5 SE-30; A-4, Los Bermejales, Elcano-Los Bermeja... 37.343311 -5.968173 -1.362888e+06 4.357127e+06 5840.376700 0 1570.286329
6 Camino de Villanueva del Pítamo, Bellavista, D... 37.344485 -5.961849 -1.362888e+06 4.357127e+06 5992.495307 0 1919.695073
7 Estación depuradora de aguas residuales Oeste ... 37.340043 -6.010548 -1.362888e+06 4.357127e+06 5855.766389 0 2953.920007
8 Zona Franca, Los Remedios, Sevilla, Andalucía,... 37.341220 -6.004226 -1.362888e+06 4.357127e+06 5604.462508 0 2413.916501
9 Calle Nao Victoria, Polígono de Astilleros, Lo... 37.342395 -5.997903 -1.362888e+06 4.357127e+06 5408.326913 0 1910.063439

OK, now let's calculate the distance to nearest hotel from every area candidate center (not only those within 300m - we want distance to closest one, regardless of how distant it is).

In [32]:
distances_to_hotel = []

for area_x, area_y in zip(xs, ys):
    min_distance = 10000
    for hot in hotels.values():
        hot_x = hot[6]
        hot_y = hot[7]
        d = calc_xy_distance(area_x, area_y, hot_x, hot_y)
        if d<min_distance:
            min_distance = d
    distances_to_hotel.append(min_distance)

df_locations['Distance to near Hotel'] = distances_to_hotel
In [33]:
df_locations.head(10)
Out[33]:
Address Latitude Longitude UTM X UTM Y Distance from center Hotels in area Distance to near Hotel
0 Zona Franca, Los Remedios, Sevilla, Andalucía,... 37.337436 -5.999789 -1.362888e+06 4.357127e+06 5992.495307 0 2475.036066
1 Tecade Yard, Calle Nao Concepción, Polígono de... 37.338611 -5.993467 -1.362888e+06 4.357127e+06 5840.376700 0 2075.368158
2 Avenida de Palmas Altas, Bellavista, Distrito ... 37.339787 -5.987144 -1.362888e+06 4.357127e+06 5747.173218 0 1748.671139
3 SE-30; A-66, Bellavista, Distrito Bellavista-L... 37.340962 -5.980821 -1.362888e+06 4.357127e+06 5715.767665 0 1478.830320
4 Clínica FREMAP, Avenida de Jerez, Los Bermejal... 37.342136 -5.974497 -1.362888e+06 4.357127e+06 5747.173218 0 1402.272846
5 SE-30; A-4, Los Bermejales, Elcano-Los Bermeja... 37.343311 -5.968173 -1.362888e+06 4.357127e+06 5840.376700 0 1570.286329
6 Camino de Villanueva del Pítamo, Bellavista, D... 37.344485 -5.961849 -1.362888e+06 4.357127e+06 5992.495307 0 1919.695073
7 Estación depuradora de aguas residuales Oeste ... 37.340043 -6.010548 -1.362888e+06 4.357127e+06 5855.766389 0 2953.920007
8 Zona Franca, Los Remedios, Sevilla, Andalucía,... 37.341220 -6.004226 -1.362888e+06 4.357127e+06 5604.462508 0 2413.916501
9 Calle Nao Victoria, Polígono de Astilleros, Lo... 37.342395 -5.997903 -1.362888e+06 4.357127e+06 5408.326913 0 1910.063439
In [36]:
print('Average distance to closest Hotel from each area center:', df_locations['Distance to near Hotel'].mean())
Average distance to closest Hotel from each area center: 1181.961665326484

OK, so on average hotel can be found within ~1182m from every area center candidate. That's fairly close, so we need to filter our areas carefully!

Let's crete a map showing heatmap / density of hotels and try to extract some meaningfull info from that. Also, let's show borders of Seville boroughs on our map and a few circles indicating distance of 1km, 2km and 3km from Seville Center.

In [37]:
seville_boroughs_url = 'http://sevilla.idesevilla.opendata.arcgis.com/datasets/6a78792622194f23b55700df2add6ae7_0.geojson'
seville_boroughs = requests.get(seville_boroughs_url).json()

def boroughs_style(feature):
    return { 'color': 'gray', 'fill': True,'fillOpacity':0.1 }
In [38]:
hotel_latlons = [[hot[2], hot[3]] for hot in hotels.values()]
In [39]:
from folium import plugins
from folium.plugins import HeatMap

map_seville = folium.Map(location=seville_center, zoom_start=13)
folium.TileLayer('cartodbpositron').add_to(map_seville) #cartodbpositron cartodbdark_matter
HeatMap(hotel_latlons).add_to(map_seville)
folium.Marker(seville_center).add_to(map_seville)
folium.Circle(seville_center, radius=1000, fill=False, color='white').add_to(map_seville)
folium.Circle(seville_center, radius=2000, fill=False, color='white').add_to(map_seville)
folium.Circle(seville_center, radius=3000, fill=False, color='white').add_to(map_seville)
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[39]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Looks like a few pockets of low hotel density closest to city center can be found south, south-weast and east from Seville Center.

South District - Maria Luisa Park & Spain Square

Analysis of popular travel guides and web sites like tripadvisor mention Maria Luisa Park & Spain Square as one of the most beautifull and interesting part of Seville.

Popular with tourists,well connected and relatively close to city center.

Let's define new, more narrow region of interest, which will include parts of South District closest to Seville Center.

In [41]:
roi_x_min = seville_center_x - 2000
roi_y_max = seville_center_y + 1000
roi_width = 5000
roi_height = 5000
roi_center_x = roi_x_min + 2500
roi_center_y = roi_y_max - 2500
roi_center_lon, roi_center_lat = xy_to_lonlat(roi_center_x, roi_center_y)
roi_center = [roi_center_lat, roi_center_lon]

map_seville = folium.Map(location=roi_center, zoom_start=14)
HeatMap(hotel_latlons).add_to(map_seville)
folium.Marker(seville_center).add_to(map_seville)
folium.Circle(roi_center, radius=2500, color='white', fill=True, fill_opacity=0.4).add_to(map_seville)
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[41]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Not bad - this nicely covers all the pockets of low hotel density in South District closest to Seville center.

Let's also create new, more dense grid of location candidates restricted to our new region of interest (let's make our location candidates 100m appart).

In [42]:
k = math.sqrt(3) / 2 # Vertical offset for hexagonal grid cells
x_step = 100
y_step = 100 * k 
roi_y_min = roi_center_y - 2500

roi_latitudes = []
roi_longitudes = []
roi_xs = []
roi_ys = []
for i in range(0, int(51/k)):
    y = roi_y_min + i * y_step
    x_offset = 50 if i%2==0 else 0
    for j in range(0, 51):
        x = roi_x_min + j * x_step + x_offset
        d = calc_xy_distance(roi_center_x, roi_center_y, x, y)
        if (d <= 2501):
            lon, lat = xy_to_lonlat(x, y)
            roi_latitudes.append(lat)
            roi_longitudes.append(lon)
            roi_xs.append(x)
            roi_ys.append(y)

print(len(roi_latitudes), 'candidate neighborhood centers generated.')
2261 candidate neighborhood centers generated.

OK. Now let's calculate two most important things for each location candidate: number of hotels in vicinity (we'll use radius of 250 meters) and distance to closest hotel.

In [43]:
def count_hotels_nearby(x, y, hotels, radius=250):    
    count = 0
    for hot in hotels.values():
        hot_x = hot[6]; hot_y = hot[7]
        d = calc_xy_distance(x, y, hot_x, hot_y)
        if d<=radius:
            count += 1
    return count

def find_nearest_hotel(x, y, hotels):
    d_min = 100000
    for hot in hotels.values():
        hot_x = hot[6]; hot_y = hot[7]
        d = calc_xy_distance(x, y, hot_x, hot_y)
        if d<=d_min:
            d_min = d
    return d_min

roi_hotel_counts = []
roi_hotels_distances = []

print('Generating data on location candidates... ', end='')
for x, y in zip(roi_xs, roi_ys):
    count = count_hotels_nearby(x, y, hotels, radius=250)
    roi_hotel_counts.append(count)
    distance = find_nearest_hotel(x, y, hotels)
    roi_hotels_distances.append(distance)
print('done.')
Generating data on location candidates... done.
In [44]:
# Let's put this into dataframe
df_roi_locations = pd.DataFrame({'Latitude':roi_latitudes,
                                 'Longitude':roi_longitudes,
                                 'X':roi_xs,
                                 'Y':roi_ys,
                                 'Hotels nearby':roi_hotel_counts,
                                 'Distance to near hotel':roi_hotels_distances})

df_roi_locations
Out[44]:
Latitude Longitude X Y Hotels nearby Distance to near hotel
0 37.356280 -5.980286 -1.368438e+06 4.347411e+06 0 283.008834
1 37.356476 -5.979232 -1.368338e+06 4.347411e+06 0 310.511371
2 37.355932 -5.986297 -1.368988e+06 4.347498e+06 1 166.745861
3 37.356128 -5.985242 -1.368888e+06 4.347498e+06 1 121.226069
4 37.356324 -5.984188 -1.368788e+06 4.347498e+06 1 146.926299
... ... ... ... ... ... ...
2256 37.398111 -5.990829 -1.368288e+06 4.352348e+06 2 169.918496
2257 37.398307 -5.989774 -1.368188e+06 4.352348e+06 2 185.892658
2258 37.398503 -5.988720 -1.368088e+06 4.352348e+06 2 188.168501
2259 37.398699 -5.987665 -1.367988e+06 4.352348e+06 1 191.530403
2260 37.398895 -5.986610 -1.367888e+06 4.352348e+06 1 240.749675

2261 rows × 6 columns

OK. Let us now filter those locations: we're interested only in locations with no more than two hotels in radius of 250 meters, and no hotels in radius of 400 meters.

In [45]:
good_hot_count = np.array((df_roi_locations['Hotels nearby']<=3))
print('Locations with no more than two hotels nearby:', good_hot_count.sum())

good_hot_distance = np.array(df_roi_locations['Distance to near hotel']>=400)
print('Locations with no hotels within 400m:', good_hot_distance.sum())

good_locations = np.logical_and(good_hot_count, good_hot_distance)
print('Locations with both conditions met:', good_locations.sum())

df_good_locations = df_roi_locations[good_locations]
Locations with no more than two hotels nearby: 1936
Locations with no hotels within 400m: 1038
Locations with both conditions met: 1038

Let's see how this looks on a map.

In [46]:
good_latitudes = df_good_locations['Latitude'].values
good_longitudes = df_good_locations['Longitude'].values

good_locations = [[lat, lon] for lat, lon in zip(good_latitudes, good_longitudes)]

map_seville = folium.Map(location=roi_center, zoom_start=14)
folium.TileLayer('cartodbpositron').add_to(map_seville)
HeatMap(hotel_latlons).add_to(map_seville)
folium.Circle(roi_center, radius=2500, color='white', fill=True, fill_opacity=0.6).add_to(map_seville)
folium.Marker(seville_center).add_to(map_seville)
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.CircleMarker([lat, lon], radius=2, color='blue', fill=True, fill_color='blue', fill_opacity=1).add_to(map_seville) 
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[46]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Looking good. We now have a bunch of locations fairly close to Seville Center, and we know that each of those locations has no more than three hotels in radius of 250m, and no hotel closer than 400m. Any of those locations is a potential candidate for a new hotel, at least based on nearby competition.

Let's now show those good locations in a form of heatmap:

In [47]:
map_seville = folium.Map(location=roi_center, zoom_start=14)
HeatMap(good_locations, radius=25).add_to(map_seville)
folium.Marker(seville_center).add_to(map_seville)
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.CircleMarker([lat, lon], radius=2, color='blue', fill=True, fill_color='blue', fill_opacity=1).add_to(map_seville)
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[47]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Looking good. What we have now is a clear indication of zones with low number of hotels in vicinity, and no hotels at all nearby.

Let us now cluster those locations to create centers of zones containing good locations. Those zones, their centers and addresses will be the final result of our analysis.

In [48]:
from sklearn.cluster import KMeans

number_of_clusters = 5

good_xys = df_good_locations[['X', 'Y']].values
kmeans = KMeans(n_clusters=number_of_clusters, random_state=0).fit(good_xys)

cluster_centers = [xy_to_lonlat(cc[0], cc[1]) for cc in kmeans.cluster_centers_]

map_seville = folium.Map(location=roi_center, zoom_start=14)
folium.TileLayer('cartodbpositron').add_to(map_seville)
HeatMap(hotel_latlons).add_to(map_seville)
folium.Circle(roi_center, radius=2500, color='white', fill=True, fill_opacity=0.4).add_to(map_seville)
folium.Marker(seville_center).add_to(map_seville)
for lon, lat in cluster_centers:
    folium.Circle([lat, lon], radius=500, color='green', fill=True, fill_opacity=0.25).add_to(map_seville) 
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.CircleMarker([lat, lon], radius=2, color='blue', fill=True, fill_color='blue', fill_opacity=1).add_to(map_seville)
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[48]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Not bad - our clusters represent groupings of most of the candidate locations and cluster centers are placed nicely in the middle of the zones 'rich' with location candidates.

Addresses of those cluster centers will be a good starting point for exploring the neighborhoods to find the best possible location based on neighborhood specifics.

Let's see those zones on a city map without heatmap, using shaded areas to indicate our clusters:

In [49]:
map_seville = folium.Map(location=roi_center, zoom_start=14)
folium.Marker(seville_center).add_to(map_seville)
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.Circle([lat, lon], radius=250, color='#00000000', fill=True, fill_color='#0066ff', fill_opacity=0.07).add_to(map_seville)
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.CircleMarker([lat, lon], radius=2, color='blue', fill=True, fill_color='blue', fill_opacity=1).add_to(map_seville)
for lon, lat in cluster_centers:
    folium.Circle([lat, lon], radius=500, color='yellow', fill=False).add_to(map_seville) 
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[49]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Let's zoom in on candidate areas in Heliopolis:

In [50]:
heliopolis=[37.3541689,-5.992948]
map_seville = folium.Map(location=heliopolis, zoom_start=15)
folium.Marker(seville_center).add_to(map_seville)
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.Circle([lat, lon], radius=250, color='#0000ff00', fill=True, fill_color='#0066ff', fill_opacity=0.07).add_to(map_seville)
for lon, lat in cluster_centers:
    folium.Circle([lat, lon], radius=500, color='yellow', fill=False).add_to(map_seville) 
folium.GeoJson(seville_boroughs, style_function=boroughs_style, name='geojson').add_to(map_seville)
map_seville
Out[50]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Finaly, let's reverse geocode those candidate area centers to get the addresses which can be presented to stakeholders.

In [53]:
candidate_area_addresses = []
print('====================================================================')
print('TOP 5 Addresses of centers of areas recommended for further analysis')
print('====================================================================\n')
'''
for lon, lat in cluster_centers:
    addr = get_address(google_api_key, lat, lon).replace(', Spain', '')
    candidate_area_addresses.append(addr)    
    x, y = lonlat_to_xy(lon, lat)
    d = calc_xy_distance(x, y, berlin_center_x, berlin_center_y)
    print('{}{} => {:.1f}km from Seville Center'.format(addr, ' '*(50-len(addr)), d/1000))
'''
    
clusters_addresses=[]
for lat, lon in cluster_centers:
    geolocator = Nominatim(user_agent="reverse_2")
    clusters_address=geolocator.reverse(str(lon)+ ',' + str(lat))
    if clusters_address is None:
        clusters_address = 'NO ADDRESS'
    clusters_addresses.append(clusters_address)
    print('★★★ {} => {:.1f}km from Seville Center'.format(clusters_address, d/1000))
====================================================================
TOP 5 Addresses of centers of areas recommended for further analysis
====================================================================

★★★ CD Parque de los Príncipes, Paseo Concejala Lola Meléndez, Tablada, Los Remedios, Sevilla, Andalucía, España => 3.5km from Seville Center
★★★ Viding Piscinas Sevilla, Avenida de la Ciudad Jardin, Ciudad Jardín, Nervión, Sevilla, Andalucía, SEVILLA, España => 3.5km from Seville Center
★★★ IES Ramón Carande, Calle Párroco Antonio González Abato, Antonio Machado, Polígono Sur, Distrito Sur, Sevilla, Andalucía, 41013, España => 3.5km from Seville Center
★★★ Santa Justa P3, Avenida Pablo Iglesias, San José Obrero, Distrito San Pablo-Santa Justa, Sevilla, Andalucía, 41018, España => 3.5km from Seville Center
★★★ Parking P8, Avenida Sanlúcar de Barrameda, Los Remedios, Sevilla, Andalucía, 41011, España => 3.5km from Seville Center

This concludes our analysis. We have created 15 addresses representing centers of zones containing locations with low number of hotels and no hotels nearby, all zones being fairly close to city center (all less than 4km from Alexanderplazt, and about half of those less than 2km from Seville Center). Although zones are shown on map with a radius of ~500 meters (green circles), their shape is actually very irregular and their centers/addresses should be considered only as a starting point for exploring area neighborhoods in search for potential hotel locations. Most of the zones are located in South District boroughs, which we have identified as interesting due to being popular with tourists, fairly close to city center and well connected by public transport.

In [54]:
map_seville = folium.Map(location=roi_center, zoom_start=14)
folium.Circle(seville_center, radius=50, color='red', fill=True, fill_color='red', fill_opacity=1).add_to(map_seville)
for lonlat, addr in zip(cluster_centers, candidate_area_addresses):
    folium.Marker([lonlat[1], lonlat[0]], popup=addr).add_to(map_seville) 
for lat, lon in zip(good_latitudes, good_longitudes):
    folium.Circle([lat, lon], radius=250, color='#0000ff00', fill=True, fill_color='#0066ff', fill_opacity=0.05).add_to(map_seville)
map_seville
Out[54]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Results and Discussion

Our analysis shows that although there is a great number of hotels in Seville (~250 in our initial area of interest which was 12x12km around Seville Center), there are pockets of low hotel density fairly close to city center. Highest concentration of hotels was detected in Casco Antiguo near Seville Center, so we focused our attention to areas south, south-west and north-east, corresponding to boroughs Los Remedios, Nervión, San-Pablo/Santa Justa.

After directing our attention to this more narrow area of interest (covering approx. 5x5km south-east from Seville Center) we first created a dense grid of location candidates (spaced 100m appart); those locations were then filtered so that those with more than three hotels in radius of 250m and those with a hotel closer than 400m were removed.

Those location candidates were then clustered to create zones of interest which contain greatest number of location candidates. Addresses of centers of those zones were also generated using reverse geocoding to be used as markers/starting points for more detailed local analysis based on other factors.

Result of all this is 5 zones containing largest number of potential new hotel locations based on number of and distance to existing hotels. This, of course, does not imply that those zones are actually optimal locations for a new hotel! Purpose of this analysis was to only provide info on areas close to Seville center but not crowded with existing hotels - it is entirely possible that there is a very good reason for small number of hotels in any of those areas, reasons which would make them unsuitable for a new hotel regardless of lack of competition in the area. Recommended zones should therefore be considered only as a starting point for more detailed analysis which could eventually result in location which has not only no nearby competition but also other factors taken into account and all other relevant conditions met.

Conclusion

Purpose of this project was to identify Seville areas close to center with low number of hotels in order to aid stakeholders in narrowing down the search for optimal location for a new hotel. By calculating hotel density distribution from Foursquare data we have first identified general boroughs that justify further analysis (South District), and then generated extensive collection of locations which satisfy some basic requirements regarding existing nearby hotels. Clustering of those locations was then performed in order to create major zones of interest (containing greatest number of potential locations) and addresses of those zone centers were created to be used as starting points for final exploration by stakeholders.

Final decission on optimal hotel location will be made by stakeholders based on specific characteristics of neighborhoods and locations in every recommended zone, taking into consideration additional factors like attractiveness of each location (proximity to park or water), levels of noise / proximity to major roads, real estate availability, prices, social and economic dynamics of every neighborhood etc.